Auto Increment 식별자의 한계와 대안

Auto Increment 식별자의 한계와 대안

한눈에 보기

단일 쓰기 DB에서는 여전히 좋은 기본값이다. 분산 생성, 병합, 오프라인 생성이 필요할 때 UUID나 시간 정렬 식별자를 검토한다.

목차

문제가 되는 상황

UUID가 분산 시스템에 잘 맞는다는 이유로 모든 테이블의 BIGINT AUTO_INCREMENT를 바꿔야 할까. 단일 쓰기 데이터베이스에서 행을 생성하고 내부 join에 사용하는 경우 Auto Increment는 여전히 단순하고 공간 효율적인 선택이다.

문제는 키 생성 주체가 여러 DB로 나뉘거나, offline client가 서버에 보내기 전에 ID를 알아야 하거나, 서로 다른 database의 데이터를 병합할 때 생긴다. 외부 URL에 순차 숫자가 노출되어 데이터 규모와 생성 순서가 드러나는 것도 제품 요구에 따라 고려할 수 있다. 대안은 이런 실제 요구가 있을 때 선택해야 한다.

이 글의 예제에 관하여

주문·레코드 테이블과 ID bit 구성은 비교를 위한 가상 예제다. 실제 서비스의 트래픽 규모나 식별자 규칙을 사용하지 않았다.

Auto Increment가 여전히 좋은 이유

CREATE TABLE orders (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  customer_id BIGINT UNSIGNED NOT NULL,
  created_at DATETIME(3) NOT NULL,
  PRIMARY KEY (id)
);

장점은 다음과 같다.

ORM도 생성 후 ID 반환을 잘 지원한다. 한 database writer가 identity를 소유하고 ID를 외부에서 미리 만들 필요가 없다면 복잡한 분산 ID 서비스보다 신뢰할 부분이 적다.

Auto Increment 값이 업무 순번, 청구서 번호, 법적 문서 번호를 자동으로 보장하는 것은 아니다. 그런 번호에는 별도의 발급 규칙과 transaction·감사 정책이 필요하다.

번호에 빈칸이 생기는 것은 정상이다

Sequence 값은 transaction rollback, insert 실패, 미사용 예약 값 때문에 건너뛸 수 있다.

START TRANSACTION;
INSERT INTO orders (customer_id, created_at)
VALUES (42, CURRENT_TIMESTAMP(3)); -- id 101 할당
ROLLBACK;

INSERT INTO orders (customer_id, created_at)
VALUES (42, CURRENT_TIMESTAMP(3)); -- 다음 값 102일 수 있음

101이 없다는 사실은 주문이 삭제되었다는 증거도, DB 오류도 아니다. ID를 연속된 업무 번호로 해석하면 “누락 번호 복구” 같은 위험한 로직을 만들게 된다.

// 잘못된 전제: 마지막 ID + 1을 다음 ID라고 가정한다.
const expectedNextOrderId = lastOrder.id + 1;

페이지 처리도 id + 1로 하지 않고 실제 존재하는 다음 행을 조건과 정렬로 조회한다.

SELECT id, customer_id, created_at
FROM orders
WHERE id > :last_seen_id
ORDER BY id ASC
LIMIT 100;

순차 ID와 보안은 별개의 문제다

/orders/1001 다음에 /orders/1002가 있을 가능성을 추측할 수 있다. 이는 데이터 규모와 생성 속도 같은 정보를 일부 노출할 수 있지만, 가장 큰 문제는 다른 사용자의 주문을 권한 검사 없이 반환하는 IDOR 취약점이다.

const order = await orderRepository.findById(request.params.orderId);

if (!order || order.customerId !== request.user.customerId) {
  throw new NotFoundError();
}

UUID로 바꾸어도 유출된 URL이나 로그를 가진 사용자가 다른 사람의 리소스를 읽을 수 있다면 취약하다. 예측 불가능한 public ID는 enumeration을 어렵게 하는 방어층일 뿐 authentication과 authorization을 대신하지 않는다.

외부에서 총 가입자 수를 추측하는 것이 비즈니스상 민감하면 내부 sequence를 직접 노출하지 않고 별도 public ID를 제공할 수 있다.

여러 writer와 데이터 병합의 한계

두 독립 DB가 각각 id=101을 생성하면 나중에 데이터를 합칠 때 충돌한다.

flowchart LR
    A[Region A DB
id 101] --> M[Merge] B[Region B DB
id 101] --> M M --> X[Primary Key Conflict]

offset과 increment 범위를 나누는 방법도 있지만 writer 추가와 재구성에 운영 비용이 든다.

Region A: 1, 3, 5, 7, ...
Region B: 2, 4, 6, 8, ...

다음 요구가 있다면 분산 생성 ID를 검토한다.

다만 전역 ID만 해결한다고 multi-region write가 완성되는 것은 아니다. 충돌하는 업무 데이터, 복제 지연, ordering, clock skew를 별도로 해결해야 한다.

대안별 생성 방식 비교

방식 크기 중앙 조정 대략적 정렬 주요 비용
Auto Increment / sequence 8바이트 DB 필요 생성 순서에 가까움 여러 writer 조정
UUID v4 16바이트 불필요 X 무작위 index 삽입
시간 정렬 UUID 16바이트 불필요 O 시간 정보·구현 통일
Snowflake 계열 보통 64비트 worker ID 조정 O clock·worker 관리
중앙 ID service 설계에 따라 service 의존 가능 가용성·latency
DB별 range 할당 정수 range allocator O range 관리·낭비

“충돌 확률이 매우 낮다”와 DB unique 보장은 다른 층이다. UUID를 사용해도 primary key 또는 unique constraint를 둔다. 문자열 36자로 저장할지 16바이트 binary로 저장할지도 index 크기와 운영 편의의 trade-off다.

UUID v4와 시간 정렬 UUID의 저장 방식은 UUID v4와 시간 정렬 UUID 비교에서 자세히 다룬다.

Snowflake 계열 ID의 구성과 비용

64비트 안에 timestamp, worker ID, 같은 시간 단위의 sequence를 배치하는 방식이 흔하다.

| timestamp | worker id | sequence |

장점은 작은 정수로 여러 worker가 대략 시간순 ID를 만들 수 있다는 것이다. 대신 다음 운영 규칙이 필요하다.

type PublicEvent = {
  // JSON number로 보내면 큰 정수 정밀도를 잃을 수 있어 문자열로 표현한다.
  id: string;
};

직접 generator를 만들기보다 검증된 구현을 사용하고, clock rollback과 worker collision을 chaos test한다. “중앙 DB를 제거했다” 대신 worker lease나 설정 배포라는 새 조정 지점이 생길 수 있다.

내부 ID와 public ID를 함께 쓰기

내부 join에는 Auto Increment를 유지하고 외부에 별도 ID를 제공하는 절충안이 있다.

CREATE TABLE records (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  public_id BINARY(16) NOT NULL,
  payload JSON NOT NULL,
  created_at DATETIME(3) NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uq_records_public_id (public_id)
);
flowchart LR
    API[API public_id] --> L[Lookup unique index]
    L --> PK[Internal BIGINT PK]
    PK --> FK[작은 foreign keys와 joins]

이 구조는 index 하나와 lookup을 추가한다. 모든 endpoint가 public ID만 쓰도록 repository 경계를 정하고, 내부 ID가 실수로 JSON과 이벤트에 노출되지 않게 presenter를 둔다.

public ID를 application에서 생성하면 DB insert 전 event correlation에도 사용할 수 있다. 두 ID를 운영해야 하는 복잡성이 이점보다 큰 작은 내부 서비스라면 하나의 key가 낫다.

분산 ID가 필요하지 않은 경우

다음 상황에서는 Auto Increment가 충분할 가능성이 높다.

UUID 도입은 key 크기 증가, 디버깅 불편, index locality, byte order와 library 호환을 함께 가져온다. “언젠가 분산할 수 있다”는 막연한 가능성보다 현재와 예상되는 writer 구조를 근거로 선택한다.

migration할 때 주의할 점

기존 BIGINT를 UUID primary key로 바로 교체하면 모든 외래 키와 보조 index를 다시 만들어야 할 수 있다. public ID만 필요한 경우에는 PK를 유지하고 unique UUID 컬럼을 추가하는 migration이 작다.

  1. nullable public_id와 unique index 준비 방식을 검토한다.
  2. 기존 행을 batch로 backfill한다.
  3. 새 insert에서 반드시 생성하도록 한다.
  4. 중복·NULL 검증 후 NOT NULL을 적용한다.
  5. API read와 URL을 점진적으로 public ID로 전환한다.
  6. 구 숫자 URL의 종료 또는 redirect 정책을 정한다.
SELECT COUNT(*) AS missing_public_ids
FROM records
WHERE public_id IS NULL;

큰 테이블에서는 online DDL 지원, replication lag, backfill throttle과 rollback을 확인한다.

실전 점검 목록

ID 생성 전략

  • 실제로 여러 writer가 중앙 조정 없이 ID를 만들어야 하는가?
  • ID gap을 업무 누락으로 잘못 해석하지 않는가?
  • public ID와 권한 검사를 서로 다른 문제로 다루는가?
  • 대안의 key 크기와 index 삽입 패턴을 측정했는가?
  • 시간 기반 ID의 clock·worker 충돌 정책이 있는가?
  • 내부·외부 ID 두 개의 운영 비용을 감수할 이유가 있는가?
  • migration에서 모든 외래 키를 바꾸지 않는 작은 경로를 검토했는가?

단일 쓰기 DB에서는 여전히 좋은 기본값이다. 분산 생성, 병합, 오프라인 생성이 필요할 때 UUID나 시간 정렬 식별자를 검토한다.

결론

Auto Increment는 단일 쓰기 DB에서 작고 단순하며 index locality가 좋은 기본값이다. 번호 gap은 정상이고 순차 ID의 추측 가능성은 권한 검사와 별개의 문제다. 여러 writer·offline 생성·데이터 병합이 실제 요구일 때 UUID나 Snowflake 계열을 검토하고, 외부 노출만 문제라면 내부 BIGINT와 별도 public ID를 함께 쓰는 작은 변경부터 고려한다.

관련 노트